📎 Doc 1
✕
📎 Doc 2
`
${v.changes.map(c=>`
${c.text}
`).join('')}
`).join('');
document.getElementById('changelog-overlay').classList.add('open');
}
function closeChangelog(){
document.getElementById('changelog-overlay').classList.remove('open');
}
// ── Project type definitions ──
const PROJECT_TYPES=[
{label:'Loco', color:'#d1d5db',text:'#374151'},
{label:'Coach', color:'#7f1d1d',text:'#ffffff'},
{label:'PGC', color:'#166534',text:'#ffffff'},
{label:'DMU', color:'#bfdbfe',text:'#1e3a8a'},
{label:'F&M',color:'#f59e0b',text:'#1c1917'},
{label:'Misc', color:'#1c1917',text:'#ffffff'},
];
// ── Color migration map — maps old stored colors to current PROJECT_TYPES colors ──
// This ensures projects saved before the color system was standardised display correctly
const COLOR_MIGRATION = {
// Old Loco colors → Loco
'#9ca3af':'#d1d5db','#6b7280':'#d1d5db','#d1d5db':'#d1d5db',
// Old Coach colors → Coach
'#7c3aed':'#7f1d1d','#6d28d9':'#7f1d1d','#7f1d1d':'#7f1d1d',
'#c8400a':'#7f1d1d',
// Old PGC colors → PGC
'#1a6b4a':'#166534','#166534':'#166534','#15803d':'#166534',
'#065f46':'#166534',
// Old DMU colors → DMU
'#1e40af':'#bfdbfe','#0e7490':'#bfdbfe','#1e3a8a':'#bfdbfe',
'#2563eb':'#bfdbfe','#bfdbfe':'#bfdbfe',
// Old F&M colors → Machineries
'#b8800a':'#f59e0b','#d97706':'#f59e0b','#f59e0b':'#f59e0b',
'#ca8a04':'#f59e0b','#92400e':'#f59e0b',
// Old Misc colors → Misc
'#374151':'#1c1917','#1f2937':'#1c1917','#1c1917':'#1c1917',
'#111827':'#1c1917',
};
function migrateColor(color){
if(!color) return PROJECT_TYPES[0].color;
const c = color.trim().toLowerCase();
return COLOR_MIGRATION[c] || c;
}
let projects=[], editId=null, activeDrawerProject=null;
const fmt=d=>d.toISOString().split('T')[0];
const off=(d,m)=>{const r=new Date(d);r.setMonth(r.getMonth()+m);return r;};
const today=new Date();
const fmtDate=d=>new Date(d).toLocaleDateString('en-MY',{day:'numeric',month:'short',year:'numeric'});
const daysLeft=end=>Math.ceil((new Date(end)-new Date())/864e5);
// ── Firebase Realtime Database ──
const FB_BASE='https://rsme-protracker-default-rtdb.firebaseio.com';
const FB_URL=FB_BASE+'/projects';
// Show a loading state on the project list
function showLoadingState(){
const sections=['tender-rows','po-rows'];
sections.forEach(id=>{
const el=document.getElementById(id);
if(el) el.innerHTML='⏳ Loading projects...
';
});
}
// Test Firebase connection
async function testFirebaseConnection(){
try{
const res=await fetch(FB_BASE+'/.json?shallow=true');
if(!res.ok) throw new Error('HTTP '+res.status);
console.log('✅ Firebase connected');
return true;
} catch(e){
console.error('❌ Firebase connection failed:',e);
return false;
}
}
// Load all projects from Firebase
async function loadFromFirebase(){
showLoadingState();
try{
const res=await fetch(FB_URL+'.json',{mode:'cors',headers:{Accept:'application/json'}});
if(!res.ok){const t=await res.text();throw new Error('HTTP '+res.status+': '+t);}
const data=await res.json();
if(data && typeof data==='object'){
projects=Object.values(data).filter(p=>p&&p.id).map(p=>({...p,color:migrateColor(p.color)}));
} else {
projects=[];
}
console.log('✅ Loaded',projects.length,'projects from Firebase');
} catch(e){
console.error('❌ Firebase load error:',e.message);
projects=[];
// Show friendly error in UI
const el=document.getElementById('tender-rows');
if(el) el.innerHTML=`⚠️ Could not connect to database.${e.message}
`;
}
render();
}
// Save all projects to Firebase (full overwrite)
async function saveToFirebase(){
try{
const obj={};
projects.forEach(p=>{
const clean={};
Object.keys(p).forEach(k=>{ if(k!=='_fbKey') clean[k]=p[k]; });
obj[clean.id]=clean;
});
const res=await fetch(FB_URL+'.json',{
method:'PUT',
headers:{
'Content-Type':'application/json',
'Accept':'application/json'
},
mode:'cors',
body:JSON.stringify(obj)
});
if(!res.ok){
const errText=await res.text();
throw new Error('HTTP '+res.status+': '+errText);
}
console.log('✅ Saved to Firebase');
} catch(e){
console.error('❌ Firebase save error:',e.message);
alert('Could not save to database: '+e.message);
}
}
// ── Date helpers: internal = YYYY-MM-DD, display = DD-MM-YYYY ──
function toDisplay(iso){ // YYYY-MM-DD → DD-MM-YYYY
if(!iso||iso.length<8) return '';
const [y,m,d]=iso.split('-');
return d+'-'+m+'-'+y;
}
function toISO(disp){ // DD-MM-YYYY → YYYY-MM-DD
if(!disp||disp.length<8) return '';
const parts=disp.split('-');
if(parts.length!==3) return '';
const [d,m,y]=parts;
return y+'-'+m+'-'+d;
}
// ── Milestone builder ──
function msStatusClass(s){
return s==='done'?'ms-status-done':s==='active'?'ms-status-active':'ms-status-pending';
}
function addMilestoneRow(name='',startISO='',endISO='',status='pending'){
const builder=document.getElementById('ms-builder');
const row=document.createElement('div');
row.className='ms-builder-row';
row.innerHTML=`
Pending
In Progress
Done
📎
✕ `;
builder.appendChild(row);
}
function formatDateInput(input){
// Auto-insert dashes as user types: 2 digits → dash, 4 digits → dash
let v=input.value.replace(/[^0-9]/g,'');
if(v.length>2) v=v.slice(0,2)+'-'+v.slice(2);
if(v.length>5) v=v.slice(0,5)+'-'+v.slice(5);
input.value=v.slice(0,10);
}
function loadMilestoneBuilder(milestones){
document.getElementById('ms-builder').innerHTML='';
if(!milestones||!milestones.length){
addMilestoneRow(); // start with one blank row
return;
}
milestones.forEach(m=>{
addMilestoneRow(m.name,m.startDate||m.date||"",m.endDate||"",m.status);
// Restore PDF if exists
if(m.pdf&&m.pdf.data){
const rows=document.querySelectorAll('#ms-builder .ms-builder-row');
const row=rows[rows.length-1];
const btn=row.querySelector('.ms-pdf-btn');
if(btn){btn.dataset.pdf=m.pdf.data;btn.dataset.pdfName=m.pdf.name||'document.pdf';btn.classList.add('has-pdf');}
}
});
}
function readMilestoneBuilder(existing){
const rows=document.querySelectorAll('#ms-builder .ms-builder-row');
return Array.from(rows).map((row,i)=>{
const name=row.querySelector('.ms-name').value.trim();
const startDisp=row.querySelector('.ms-start').value.trim();
const endDisp=row.querySelector('.ms-end').value.trim();
const status=row.querySelector('.ms-status').value;
const em=existing?.milestones?.[i];
const pdfBtn=row.querySelector('.ms-pdf-btn');
const pdfData=pdfBtn?.dataset?.pdf||'';
const pdfName=pdfBtn?.dataset?.pdfName||'';
return{
id:em?.id||('m'+Date.now()+'_'+i),
name:name||'Milestone',
startDate:toISO(startDisp)||'',
endDate:toISO(endDisp)||'',
date:toISO(startDisp)||toISO(endDisp)||fmt(new Date()), // keep legacy 'date' for timeline compat
status,
photos:em?.photos||[],
pdf:pdfData?{data:pdfData,name:pdfName}:null
};
}).filter(m=>m.name!=='Milestone');
}
// ── Toast notification system ──
function showToast(type, icon, title, message, duration=4000){
const container=document.getElementById('toast-container');
const toast=document.createElement('div');
toast.className=`toast ${type}`;
toast.innerHTML=`
${icon}
${title}
${message?`
${message}
`:''}
✕ `;
container.appendChild(toast);
// Auto dismiss
const timer=setTimeout(()=>dismissToast(toast), duration);
toast._timer=timer;
}
function dismissToast(toast){
if(!toast||!toast.parentElement) return;
clearTimeout(toast._timer);
toast.classList.add('hiding');
setTimeout(()=>toast.parentElement&&toast.parentElement.removeChild(toast), 300);
}
// Convenience wrappers
function toastSuccess(title, msg){ showToast('success','✅',title,msg); }
function toastInfo(title, msg){ showToast('info','ℹ️',title,msg); }
function toastWarning(title, msg){ showToast('warning','⚠️',title,msg); }
function toastPhoto(title, msg){ showToast('photo','📸',title,msg); }
function toastDocument(title, msg){ showToast('document','📄',title,msg); }
// ── Multi-PDF upload helpers (3 slots per section) ──
// Storage: { 'tender-ref': [null,null,null], 'po-ref': [null,null,null], 'eot-ref': [null,null,null] }
const multiPdfData = {
'tender-ref': [null, null, null],
'po-ref': [null, null, null],
'eot-ref': [null, null, null],
};
function handleMultiPdfUpload(input, prefix, slot) {
const file = input.files[0];
if (!file) return;
if (file.type !== 'application/pdf') { alert('Please upload a PDF file.'); input.value = ''; return; }
if (file.size > 20 * 1024 * 1024) { alert('File size must be under 20MB.'); input.value = ''; return; }
const reader = new FileReader();
reader.onload = e => {
multiPdfData[prefix][slot - 1] = { name: file.name, base64: e.target.result };
updateMultiPdfSlotUI(prefix, slot);
const sectionLabel={'tender-ref':'Tender','po-ref':'PO','eot-ref':'EOT Approval'}[prefix]||prefix;
toastDocument('Document Uploaded', `"${file.name}" added to ${sectionLabel} Ref. Docs.`);
};
reader.readAsDataURL(file);
}
function clearMultiPdf(prefix, slot) {
multiPdfData[prefix][slot - 1] = null;
document.getElementById(`${prefix}-file-${slot}`).value = '';
updateMultiPdfSlotUI(prefix, slot);
}
function updateMultiPdfSlotUI(prefix, slot) {
const doc = multiPdfData[prefix][slot - 1];
const lbl = document.getElementById(`${prefix}-lbl-${slot}`);
const nameEl = document.getElementById(`${prefix}-name-${slot}`);
const clrBtn = document.getElementById(`${prefix}-clr-${slot}`);
const slotEl = document.getElementById(`${prefix}-slot-${slot}`);
if (doc) {
lbl.style.display = 'none';
nameEl.textContent = doc.name;
nameEl.style.display = 'inline';
clrBtn.style.display = 'inline';
if (slotEl) slotEl.style.borderStyle = 'solid';
} else {
lbl.style.display = 'inline-flex';
nameEl.style.display = 'none';
clrBtn.style.display = 'none';
if (slotEl) slotEl.style.borderStyle = 'dashed';
}
}
function loadMultiPdfUI(prefix, docs) {
// docs is array of up to 3 {name,base64} or null
const arr = Array.isArray(docs) ? docs : [docs, null, null];
multiPdfData[prefix] = [arr[0] || null, arr[1] || null, arr[2] || null];
[1, 2, 3].forEach(slot => updateMultiPdfSlotUI(prefix, slot));
}
function clearMultiPdfAll(prefix) {
multiPdfData[prefix] = [null, null, null];
[1, 2, 3].forEach(slot => {
document.getElementById(`${prefix}-file-${slot}`).value = '';
updateMultiPdfSlotUI(prefix, slot);
});
}
function getMultiPdfDocs(prefix) {
return multiPdfData[prefix].filter(Boolean);
}
function openMultiPdf(doc) {
if (!doc || !doc.base64) return;
const link = document.createElement('a');
link.href = doc.base64;
link.download = doc.name || 'document.pdf';
link.click();
}
// Legacy aliases kept so any old calls don't break
function clearRefDoc(){ clearMultiPdfAll('tender-ref'); }
function clearPoRefDoc(){ clearMultiPdfAll('po-ref'); }
function clearEotRefDoc(){ clearMultiPdfAll('eot-ref'); }
function openRefDoc(doc){ openMultiPdf(doc); }
// ── Get project type label from color ──
function getTypeLabel(color){
const t=PROJECT_TYPES.find(x=>x.color===color);
return t?t.label:'—';
}
function getTypeText(color){
const t=PROJECT_TYPES.find(x=>x.color===color);
return t?t.text:'#ffffff';
}
// ── Project type / color selector builder ──
const cr=document.getElementById('color-row');
PROJECT_TYPES.forEach(t=>{
const s=document.createElement('div');
s.className='type-swatch';
s.style.background=t.color;
s.style.color=t.text;
s.dataset.c=t.color;
s.innerHTML=` ${t.label}`;
s.onclick=()=>{document.querySelectorAll('.type-swatch').forEach(x=>x.classList.remove('sel'));s.classList.add('sel');};
cr.appendChild(s);
});
const getColor=()=>document.querySelector('.type-swatch.sel')?.dataset.c||PROJECT_TYPES[0].color;
const setColor=c=>{
// Normalise color string for comparison
const norm=c?c.trim().toLowerCase():'';
let matched=false;
document.querySelectorAll('.type-swatch').forEach(x=>{
const match=x.dataset.c&&x.dataset.c.trim().toLowerCase()===norm;
x.classList.toggle('sel',match);
if(match) matched=true;
});
// If no match found (e.g. old color value), fall back to closest PROJECT_TYPE
if(!matched){
const fallback=PROJECT_TYPES.find(t=>t.color.toLowerCase()===norm)||PROJECT_TYPES[0];
document.querySelectorAll('.type-swatch').forEach(x=>{
x.classList.toggle('sel',x.dataset.c&&x.dataset.c.trim().toLowerCase()===fallback.color.toLowerCase());
});
}
};
document.getElementById('f-status').addEventListener('change',function(){
if(editId) return; // when editing, always keep delay box visible
const show=this.value==='delayed';
document.getElementById('delay-section').style.display=show?'block':'none';
});
function togglePOFields(cat){
document.getElementById('tender-fields').style.display=cat==='tender'?'block':'none';
document.getElementById('po-fields').style.display=cat==='po'?'block':'none';
}
// ── KPIs — Active Tender projects only (excludes completed) ──
function updateKPIs(){
const allTender=projects.filter(p=>p.category==='tender');
const tender=allTender.filter(p=>!isCompleted(p)); // active tender only
const avg=tender.length?Math.round(tender.reduce((s,p)=>s+p.progress,0)/tender.length):0;
const risk=tender.filter(p=>p.status==='delayed').length;
document.getElementById('kpi-total').textContent=tender.length;
document.getElementById('kpi-sub-total').textContent=
tender.length===0?'No active tender projects':'Active tender projects';
document.getElementById('kpi-avg').textContent=avg+'%';
document.getElementById('kpi-avg-bar').style.width=avg+'%';
document.getElementById('kpi-sub-avg').textContent=
avg>=80?'Excellent progress ✓':avg>=50?'Good pace':'Needs attention';
document.getElementById('kpi-risk').textContent=risk;
document.getElementById('kpi-sub-risk').textContent=
risk===0?'All on track ✓':risk+' project'+(risk>1?'s':'')+' need attention';
}
const BADGE={'pre-award':['s-active','Pre-Award'],'on-track':['s-on-track','On Track'],'delayed':['s-delayed','Delayed'],'complete':['s-complete','Complete']};
// ── Collapsible toggle ──
function toggleCollapse(key){
const body=document.getElementById('collapse-'+key);
const chev=document.getElementById('chev-'+key);
const isCollapsed=body.classList.toggle('collapsed');
chev.style.transform=isCollapsed?'rotate(-90deg)':'rotate(0deg)';
}
// ── Shared row renderer (Tender, PO, Completed) — Project | Ref No. | Amount | Contractor | Progress | Actions ──
function renderRefRows(list, refLabel, amtLabel, getRef, getAmt, emptyMsg){
if(!list.length) return `${emptyMsg}
`;
return list.map((p,i)=>{
const [bcls,blbl]=BADGE[p.status]||['s-active','Active'];
const typeLabel=getTypeLabel(p.color);
const typeText=getTypeText(p.color);
const refVal=getRef(p)||'—';
const amtRaw=getAmt(p);
const amtVal=amtRaw?'MYR '+Number(amtRaw).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
const dueSoon=isDueSoon(p);
return ``;
}).join('');
}
function renderTenderRows(list){
return renderRefRows(list,'Tender No.','Tender Amount (MYR)',p=>p.tenderNo,p=>p.tenderAmount,'No tender projects yet.');
}
// Legacy alias kept so any remaining calls don't break
function renderProjectRows(list){ return renderTenderRows(list); }
// ── PO rows (Project | PO No. | PO Amount | Contractor | Progress | Actions) ──
function renderPORows(list){
if(!list.length) return 'No PO projects yet.
';
return list.map((p,i)=>{
const [bcls,blbl]=BADGE[p.status]||['s-active','Active'];
const typeLabel=getTypeLabel(p.color);
const typeText=getTypeText(p.color);
const poNo=p.poNo||'—';
const poAmt=p.poAmount?'MYR '+Number(p.poAmount).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
const dueSoon=isDueSoon(p);
return ``;
}).join('');
}
// ── Render all 3 category lists ──
// A project is "completed" when progress===100 or status==='complete',
// regardless of its original category (tender/po).
function isCompleted(p){ return p.progress===100||p.status==='complete'; }
// Returns true if an active project's effective deadline is within 90 days (3 months)
function isDueSoon(p){
if(isCompleted(p)) return false;
const effectiveEnd=p.newDeadline&&p.newDeadline.trim()?p.newDeadline:p.end;
if(!effectiveEnd) return false;
const daysLeft=Math.ceil((new Date(effectiveEnd)-new Date())/864e5);
return daysLeft>=0 && daysLeft<=90;
}
// ── Type filter state (null = show all) ──
const activeFilters={tender:null, po:null, completed:null};
const activeLocFilters={tender:null, po:null, completed:null};
const LOCATIONS=['RSM','KLS','TUM','KLP','GMS','KB'];
function buildFilterBars(){
['tender','po','completed'].forEach(section=>{
const bar=document.getElementById('filter-'+section);
if(!bar) return;
bar.innerHTML='';
// ── Unit type chips ──
PROJECT_TYPES.forEach(t=>{
const chip=document.createElement('div');
chip.className='type-chip'+(activeFilters[section]===t.color?' active':'');
chip.style.background=t.color;
chip.style.color=t.text;
chip.innerHTML=` ${t.label}`;
chip.onclick=()=>{
activeFilters[section]=activeFilters[section]===t.color?null:t.color;
buildFilterBars();
renderList();
};
bar.appendChild(chip);
});
// ── Divider ──
const div=document.createElement('div');
div.style.cssText='width:1px;height:20px;background:var(--border);flex-shrink:0;margin:0 4px;align-self:center;';
bar.appendChild(div);
// ── Location chips ──
LOCATIONS.forEach(loc=>{
const chip=document.createElement('div');
chip.className='type-chip'+(activeLocFilters[section]===loc?' active':'');
chip.style.background='var(--surface)';
chip.style.color='var(--ink)';
chip.style.border='1.5px solid var(--border)';
chip.innerHTML=loc;
chip.onclick=()=>{
activeLocFilters[section]=activeLocFilters[section]===loc?null:loc;
buildFilterBars();
renderList();
};
bar.appendChild(chip);
});
});
}
function applyFilter(list, section){
const f=activeFilters[section];
const lf=activeLocFilters[section];
let result=f?list.filter(p=>p.color===f):list;
if(lf) result=result.filter(p=>p.location===lf);
return result;
}
function getSearchQuery(section){
const el=document.getElementById('search-'+section);
return el?el.value.trim().toLowerCase():'';
}
function applySearch(list, section){
const q=getSearchQuery(section);
if(!q) return list;
return list.filter(p=>{
const ref=p.category==='po'?p.poNo:p.tenderNo;
return (p.name||'').toLowerCase().includes(q)
|| (ref||'').toLowerCase().includes(q)
|| (p.contact||'').toLowerCase().includes(q);
});
}
function renderList(){
const tender=applySearch(applyFilter(projects.filter(p=>p.category==='tender'&&!isCompleted(p)),'tender'),'tender');
const po=applySearch(applyFilter(projects.filter(p=>p.category==='po'&&!isCompleted(p)),'po'),'po');
const completed=applySearch(applyFilter(projects.filter(p=>isCompleted(p)),'completed'),'completed');
document.getElementById('list-tender').innerHTML=renderTenderRows(tender);
document.getElementById('list-po').innerHTML=renderPORows(po);
// Completed: render each row using its original category format (Tender or PO)
// Update column headers based on majority type (or mixed)
const cTender=completed.filter(p=>p.category==='tender');
const cPO=completed.filter(p=>p.category==='po');
const refHdr=document.getElementById('head-completed-ref');
const amtHdr=document.getElementById('head-completed-amt');
if(cTender.length>0&&cPO.length===0){
refHdr.textContent='Tender No.';amtHdr.textContent='Tender Amount (MYR)';
} else if(cPO.length>0&&cTender.length===0){
refHdr.textContent='PO No.';amtHdr.textContent='PO Amount (MYR)';
} else {
refHdr.textContent='Ref No.';amtHdr.textContent='Amount (MYR)';
}
// Render using correct accessor per project's original category
document.getElementById('list-completed').innerHTML=completed.length
? completed.map((p,i)=>{
const [bcls,blbl]=BADGE[p.status]||['s-active','Active'];
const typeLabel=getTypeLabel(p.color);
const typeText=getTypeText(p.color);
const refVal=(p.category==='po'?p.poNo:p.tenderNo)||'—';
const amtRaw=p.category==='po'?p.poAmount:p.tenderAmount;
const amtVal=amtRaw?'MYR\u00a0'+Number(amtRaw).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
return ``;
}).join('')
: 'No completed projects yet.
';
document.getElementById('cnt-tender').textContent=tender.length;
document.getElementById('cnt-po').textContent=po.length;
document.getElementById('cnt-completed').textContent=completed.length;
}
// ── Drawer ──
function openDrawer(id){
// Coerce id for comparison — Firebase returns string keys, local IDs may be numbers
const p=findProject(id);if(!p)return;
activeDrawerProject=p;
document.getElementById('d-dot').style.background=p.color;
document.getElementById('d-name').textContent=p.name;
const catBadgeEl=document.getElementById('d-cat-badge');
if(p.category==='tender'){catBadgeEl.textContent='Tender';catBadgeEl.className='cat-badge cb-tender';}
else if(p.category==='po'){catBadgeEl.textContent='PO';catBadgeEl.className='cat-badge cb-po';}
else{catBadgeEl.textContent='Completed';catBadgeEl.className='cat-badge cb-completed';}
document.getElementById('d-desc').textContent=p.desc||'—';
// ── Overview: Unit badge ──
const ovUnit=document.getElementById('d-ov-unit-badge');
const typeLabel=getTypeLabel(p.color);
const typeText=getTypeText(p.color);
ovUnit.textContent=typeLabel||'—';
ovUnit.style.background=p.color||'var(--surface2)';
ovUnit.style.color=typeText||'var(--ink)';
// ── Overview: Location badge ──
const ovLocWrap=document.getElementById('d-ov-location-wrap');
const ovLocBadge=document.getElementById('d-ov-location-badge');
if(p.location&&p.location.trim()){
ovLocBadge.textContent=p.location;
ovLocWrap.style.display='block';
} else {
ovLocWrap.style.display='none';
}
// Location badge
const locEl=document.getElementById('d-location-badge');
if(p.location&&p.location.trim()){
locEl.textContent=p.location;
locEl.style.display='inline-flex';
} else {
locEl.style.display='none';
}
// Reference strip (Tender or PO)
const refStrip=document.getElementById('d-ref-strip');
const isTender=p.category==='tender';
const refNo=isTender?p.tenderNo:p.poNo;
const refAmt=isTender?p.tenderAmount:p.poAmount;
if(refNo||refAmt){
const color=isTender?'#1a6b4a':'#1e40af';
const bg=isTender?'#f0fdf4':'#eff6ff';
const border=isTender?'#bbf7d0':'#bfdbfe';
const textColor=isTender?'#14532d':'#1e3a8a';
refStrip.style.background=bg;
refStrip.style.border='1px solid '+border;
document.getElementById('d-ref-no-lbl').textContent=isTender?'Tender Number':'PO Number';
document.getElementById('d-ref-no-lbl').style.color=color;
document.getElementById('d-ref-no').textContent=refNo||'—';
document.getElementById('d-ref-no').style.color=textColor;
document.getElementById('d-ref-amt-lbl').textContent=isTender?'Tender Amount':'PO Amount';
document.getElementById('d-ref-amt-lbl').style.color=color;
document.getElementById('d-ref-amount').textContent=refAmt?'MYR '+Number(refAmt).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
document.getElementById('d-ref-amount').style.color=textColor;
refStrip.style.display='flex';
} else {refStrip.style.display='none';}
document.getElementById('d-pct').textContent=p.progress+'%';
// Reference document bar (Tender or PO) — show up to 3 docs
const refDocBar=document.getElementById('d-ref-doc-bar');
const activeDocs=p.category==='tender'
?(p.tenderRefDocs||[p.tenderRefDoc].filter(Boolean))
:(p.poRefDocs||[p.poRefDoc].filter(Boolean));
const validDocs=(activeDocs||[]).filter(d=>d&&d.name);
if(validDocs.length){
const nameEl=document.getElementById('d-ref-doc-name');
nameEl.innerHTML='';
validDocs.forEach((d,i)=>{
const sp=document.createElement('span');
sp.style.cssText='display:inline-flex;align-items:center;gap:6px;cursor:pointer;';
sp.textContent='📄 '+d.name;
sp.onclick=()=>openMultiPdf(d);
nameEl.appendChild(sp);
if(i0?'+'+daysDelayed+'d':daysDelayed+'d';
ndStat.style.display='block';
ddStat.style.display='block';
} else {
ndStat.style.display='none';
ddStat.style.display='none';
}
// Delay box
const delayBox=document.getElementById('d-delay-box');
const hasDelay=p.delay&&p.delay.trim();
const hasMit=p.mitigation&&p.mitigation.trim();
const hasRev=p.revisedTimeline&&p.revisedTimeline.trim();
const hasEot=p.eotInformed||p.eotSubmitted||p.eotMpc||p.eotApproved||p.eotNotes||(p.eotRefDocs&&p.eotRefDocs.some(d=>d))||(p.eotRefDoc);
if(hasDelay||hasMit||hasRev||hasEot){
document.getElementById('d-delay-text').textContent=p.delay||'—';
const mw=document.getElementById('d-mitigation-wrap');
if(hasMit){document.getElementById('d-mitigation-text').textContent=p.mitigation;mw.style.display='block';}else{mw.style.display='none';}
const rw=document.getElementById('d-revised-wrap');
if(hasRev){document.getElementById('d-revised-text').textContent=p.revisedTimeline;rw.style.display='block';}else{rw.style.display='none';}
// EOT section
const eotWrap=document.getElementById('d-eot-wrap');
if(hasEot){
eotWrap.style.display='flex';
const setEot=(wrapId,valId,date)=>{
const w=document.getElementById(wrapId),v=document.getElementById(valId);
if(date){v.textContent=fmtDate(date);w.style.display='block';}else{w.style.display='none';}
};
setEot('d-eot-informed-wrap','d-eot-informed-val',p.eotInformed);
setEot('d-eot-submitted-wrap','d-eot-submitted-val',p.eotSubmitted);
setEot('d-eot-mpc-wrap','d-eot-mpc-val',p.eotMpc);
setEot('d-eot-approved-wrap','d-eot-approved-val',p.eotApproved);
const nw=document.getElementById('d-eot-notes-wrap'),nv=document.getElementById('d-eot-notes-val');
if(p.eotNotes){nv.textContent=p.eotNotes;nw.style.display='block';}else{nw.style.display='none';}
const erw=document.getElementById('d-eot-ref-doc-wrap');
const eotDocs=(p.eotRefDocs||[p.eotRefDoc].filter(Boolean)||[]).filter(d=>d&&d.name);
if(eotDocs.length){
const eotNameEl=document.getElementById('d-eot-ref-doc-name');
eotNameEl.innerHTML='';
eotDocs.forEach((d,i)=>{
const sp=document.createElement('span');
sp.style.cssText='display:inline-flex;align-items:center;gap:6px;cursor:pointer;';
sp.textContent='📄 '+d.name;
sp.onclick=()=>openMultiPdf(d);
eotNameEl.appendChild(sp);
if(i`
${m.name}
${m.startDate?fmtDate(m.startDate):"—"}${m.endDate?" → "+fmtDate(m.endDate):""}
${m.status==='done'?'Done':m.status==='active'?'In Progress':'Pending'}
${m.pdf&&m.pdf.data?`
📄 Doc `:''}
`).join(''):'No milestones yet
';
// Timeline
renderDrawerTimeline(p);
// Milestones+photos
renderDrawerMilestones(p);
switchDrawerTab('overview',document.querySelector('.drawer-tab'));
document.getElementById('drawer-overlay').classList.add('open');
}
// ── Timeline inside drawer ──
function renderDrawerTimeline(p){
const inner=document.getElementById('d-tl-inner');
if(!p.start){inner.innerHTML='No timeline data
';return;}
const hasNewDeadline=p.newDeadline&&p.newDeadline.trim();
const hasRevNotes=p.revisedTimeline&&p.revisedTimeline.trim();
// Date range: always span from start to the LATER of original end or new deadline
const minD=new Date(p.start);
const origEnd=new Date(p.end);
const newEnd=hasNewDeadline?new Date(p.newDeadline):null;
const maxD=newEnd&&newEnd>origEnd?newEnd:origEnd;
// Add padding so end markers aren't clipped
const paddedMax=new Date(maxD);
paddedMax.setDate(paddedMax.getDate()+Math.max(10, Math.round((maxD-minD)/864e5*0.06)));
const totalMs=paddedMax-minD;
const toPos=d=>Math.max(0,Math.min(98,((new Date(d)-minD)/totalMs)*100));
const todayPct=toPos(new Date());
const origEndPct=toPos(origEnd);
const newEndPct=hasNewDeadline?toPos(newEnd):null;
const daysDelayed=hasNewDeadline?Math.round((newEnd-origEnd)/864e5):0;
// Month labels spanning full range
const months=[];
const cur=new Date(minD.getFullYear(),minD.getMonth(),1);
while(cur<=paddedMax){
months.push(cur.toLocaleDateString('en',{month:'short',year:'2-digit'}));
cur.setMonth(cur.getMonth()+1);
}
const monthHtml=`${months.map(m=>`
${m}
`).join('')}
`;
// Progress fill: spans from start to origEnd (or newEnd if set) proportionally
const barWidth=hasNewDeadline?newEndPct:origEndPct;
const fillWidth=barWidth*(p.progress/100);
// ── Deadline markers — label centred on the line ──
// Original deadline: grey vertical line + pill label centred on the line
const origMarker=`
`;
// New deadline: red vertical line + pill label centred on the line
const newMarker=hasNewDeadline?`
`:'';
// Delay extension shading between orig and new end
const delayShading=hasNewDeadline?`
`:'';
// Today line — neon green, hidden for completed projects
const isComplete=p.progress===100||p.status==='complete';
const today=new Date(); today.setHours(0,0,0,0);
const todayLine=isComplete?'':
`
`;
// ── Completion marker — ONLY shown when project is 100% complete ──
// Uses last 'done' milestone end date as the official completion date
const doneMilestones=p.milestones.filter(m=>m.status==='done');
const lastDone=isComplete&&doneMilestones.length?doneMilestones.reduce((a,b)=>{
const aD=new Date(a.endDate||a.startDate||a.date||0);
const bD=new Date(b.endDate||b.startDate||b.date||0);
return bD>aD?b:a;
}):null;
const completionDate=lastDone?(lastDone.endDate||lastDone.startDate||lastDone.date):null;
const completionPct=completionDate?toPos(completionDate):null;
const completionMarker=completionDate?`
`:'';
// ── Overdue shading — today has passed the effective deadline and project not complete ──
const effectiveDeadline=hasNewDeadline?new Date(p.newDeadline):origEnd;
const isOverdue=!isComplete&&today>effectiveDeadline;
const overdueShading=isOverdue?`
`:'';
// ── Project bar row — tall and prominent ──
const gantt=`
Project
${delayShading}
${overdueShading}
${p.progress}%
${origMarker}
${newMarker}
${completionMarker}
${todayLine}
`;
// ── Milestone bar rows ──
const msColors={done:'var(--success)',active:'var(--warning)',pending:'#bbb',overdue:'#c8400a'};
const msTextColors={done:'white',active:'#1c1917',pending:'#6b7280',overdue:'white'};
const msRows=p.milestones.map(m=>{
const startD=m.startDate||m.date||p.start;
const endD=m.endDate||m.startDate||m.date||p.start;
// Determine effective status — overdue if end date passed and not done
const msEndDate=new Date(endD); msEndDate.setHours(0,0,0,0);
const effectiveStatus=(m.status!=='done'&&msEndDate `
:`
${dateLabel}${overdueFlag}
`;
// Row label — red if overdue
const labelColor=effectiveStatus==='overdue'?'#c8400a':effectiveStatus==='done'?'var(--ink2)':'var(--ink)';
const labelStyle=effectiveStatus==='done'?'text-decoration:line-through;':'';
return `
${m.name}
${barContent}
${todayLine}
`;
}).join('');
// Date labels row — start and end flanking the chart
const dateLabels=`
▶ ${fmtDate(p.start)}
${hasNewDeadline?''+fmtDate(p.end)+' → '+fmtDate(p.newDeadline)+' ':fmtDate(p.end)}
${completionDate?`✓ Completed: ${fmtDate(completionDate)} `:''}
`;
// Delay summary pill
const delaySummary=hasNewDeadline?`
⚠ Delayed by ${daysDelayed} day${daysDelayed!==1?'s':''}
Original: ${fmtDate(p.end)} → New: ${fmtDate(p.newDeadline)}
${hasRevNotes?`
${p.revisedTimeline}
`:''}
`:'';
// Legend
const legend=`
Done
In Progress
Pending
Overdue
${isComplete?' Completed ':""}
Original Deadline
${hasNewDeadline?` New Deadline `:''}
${!isComplete?' Today ':""}
`;
inner.innerHTML=`${monthHtml}${gantt}${msRows}${dateLabels}${delaySummary}${legend}`;
}
// ── Milestones + photos ──
function renderDrawerMilestones(p){
const list=document.getElementById('d-ms-list');
if(!p.milestones.length){list.innerHTML='
No milestones added
';return;}
list.innerHTML=p.milestones.map(m=>`
${m.name}
${m.startDate?fmtDate(m.startDate):"—"}${m.endDate?" → "+fmtDate(m.endDate):""}
▼
${m.photos&&m.photos.length?'':'
No photos yet — upload site photos below.
'}
${renderPhotos(m)}
+ Add Photo
`).join('');
}
function renderPhotos(m){
if(!m.photos||!m.photos.length)return'';
return m.photos.map((src,i)=>`
✕
`).join('');
}
function toggleMs(mid){document.getElementById('msc-'+mid).classList.toggle('open');}
// ── Compress image via Canvas (max 1200px, JPEG 70%) ──
function compressImage(file, callback){
const reader=new FileReader();
reader.onload=ev=>{
const img=new Image();
img.onload=()=>{
const MAX=1200;
let w=img.width, h=img.height;
if(w>MAX||h>MAX){
if(w>h){h=Math.round(h*MAX/w);w=MAX;}
else{w=Math.round(w*MAX/h);h=MAX;}
}
const canvas=document.createElement('canvas');
canvas.width=w; canvas.height=h;
const ctx=canvas.getContext('2d');
ctx.drawImage(img,0,0,w,h);
callback(canvas.toDataURL('image/jpeg',0.70));
};
img.src=ev.target.result;
};
reader.readAsDataURL(file);
}
function handlePhotoUpload(event,pid,mid){
const p=findProject(pid);if(!p)return;
const m=p.milestones.find(x=>String(x.id)===String(mid));if(!m)return;
if(!m.photos)m.photos=[];
const files=Array.from(event.target.files);
let done=0;
files.forEach(f=>{
compressImage(f, compressed=>{
m.photos.push(compressed);
done++;
if(done===files.length){
// Re-render milestone card photos
const pg=document.getElementById('pg-'+mid);
if(pg)pg.innerHTML=renderPhotos(m);
// Hide "no photos" note
const card=document.getElementById('msc-'+mid);
if(card){const note=card.querySelector('.ms-no-photos');if(note)note.style.display='none';}
// Save to Firebase
const idx=findProjectIndex(pid);
if(idx>=0) projects[idx]=p;
saveToFirebase();
const count=files.length;
toastPhoto('Photo'+(count>1?'s':'')+' Added',count+' photo'+(count>1?'s':'')+' uploaded to "'+m.name+'".');
}
});
});
event.target.value='';
}
function deletePhoto(event,mid,idx){
event.stopPropagation();
const p=activeDrawerProject;if(!p)return;
const m=p.milestones.find(x=>String(x.id)===String(mid));if(!m)return;
if(!confirm('Delete this photo?'))return;
m.photos.splice(idx,1);
const pg=document.getElementById('pg-'+mid);
if(pg)pg.innerHTML=renderPhotos(m);
if(!m.photos.length){
const card=document.getElementById('msc-'+mid);
if(card){const note=card.querySelector('.ms-no-photos');if(note)note.style.display='';}
}
// Save deletion to Firebase
const pidx=findProjectIndex(p.id);
if(pidx>=0) projects[pidx]=p;
saveToFirebase();
toastWarning('Photo Removed','Photo deleted from "'+m.name+'".');
}
function openLightbox(src){document.getElementById('lightbox-img').src=src;document.getElementById('lightbox').classList.add('open');}
function closeLightbox(){document.getElementById('lightbox').classList.remove('open');}
function switchDrawerTab(tab,btn){
document.querySelectorAll('.drawer-tab').forEach(b=>b.classList.remove('active'));
if(btn)btn.classList.add('active');
document.getElementById('dtab-overview').style.display=tab==='overview'?'block':'none';
document.getElementById('dtab-timeline').style.display=tab==='timeline'?'block':'none';
document.getElementById('dtab-milestones').style.display=tab==='milestones'?'block':'none';
}
// drawer closes only via Close button
function closeDrawerBtn(){document.getElementById('drawer-overlay').classList.remove('open');activeDrawerProject=null;}
// ── Robust project finder — handles String/Number ID mismatch ──
function findProject(id){
const sid=String(id);
return projects.find(x=>String(x.id)===sid);
}
function findProjectIndex(id){
const sid=String(id);
return projects.findIndex(x=>String(x.id)===sid);
}
// ── Modal ──
function openModal(id){
editId=id?String(id):null;
document.getElementById('modal-title').textContent=id?'EDIT PROJECT':'ADD PROJECT';
document.getElementById('modal-delete-btn').style.display=id?'inline-flex':'none';
if(id){
const p=findProject(id);
if(!p){ console.error('openModal: project not found for id=',id); return; }
document.getElementById('f-name').value=p.name;
document.getElementById('f-desc').value=p.desc;
document.getElementById('f-contact').value=p.contact;
document.getElementById('f-start').value=p.start;
document.getElementById('f-end').value=p.end;
document.getElementById('f-status').value=p.status;
document.getElementById('f-progress').value=p.progress;
document.getElementById('pval').textContent=p.progress+'%';
loadMilestoneBuilder(p.milestones);
document.getElementById('f-category').value=(p.category==='po'?'po':'tender');
togglePOFields(p.category);
document.getElementById('f-tender-no').value=p.tenderNo||'';
setCurrencyValue('f-tender-amount',p.tenderAmount);
loadMultiPdfUI('tender-ref', p.tenderRefDocs||p.tenderRefDoc||null);
loadMultiPdfUI('po-ref', p.poRefDocs||p.poRefDoc||null);
loadMultiPdfUI('eot-ref', p.eotRefDocs||p.eotRefDoc||null);
document.getElementById('f-po-no').value=p.poNo||'';
setCurrencyValue('f-po-amount',p.poAmount);
document.getElementById('f-delay').value=p.delay||'';
document.getElementById('f-mitigation').value=p.mitigation||'';
document.getElementById('f-revised-timeline').value=p.revisedTimeline||'';
document.getElementById('f-new-deadline').value=p.newDeadline||'';
document.getElementById('f-eot-informed').value=p.eotInformed||'';
document.getElementById('f-eot-submitted').value=p.eotSubmitted||'';
document.getElementById('f-eot-mpc').value=p.eotMpc||'';
document.getElementById('f-eot-approved').value=p.eotApproved||'';
document.getElementById('f-eot-notes').value=p.eotNotes||'';
document.getElementById('f-location').value=p.location||'';
document.getElementById('delay-section').style.display='block';
setColor(migrateColor(p.color));
} else {
['f-name','f-desc','f-contact','f-delay','f-mitigation','f-revised-timeline','f-eot-notes'].forEach(fid=>document.getElementById(fid).value='');
['f-eot-informed','f-eot-submitted','f-eot-mpc','f-eot-approved'].forEach(fid=>document.getElementById(fid).value='');
loadMilestoneBuilder([]);
document.getElementById('f-new-deadline').value='';
document.getElementById('f-progress').value=0;document.getElementById('pval').textContent='0%';
document.getElementById('f-status').value='pre-award';
document.getElementById('f-category').value='tender';
togglePOFields('tender');
document.getElementById('f-location').value='';
document.getElementById('f-tender-no').value='';
document.getElementById('f-tender-amount').value='';
document.getElementById('f-po-amount').value='';
document.getElementById('f-tender-amount').value='';
clearRefDoc();clearPoRefDoc();clearEotRefDoc();
document.getElementById('f-po-no').value='';
document.getElementById('f-po-amount').value='';
document.getElementById('delay-section').style.display='none';
const s=new Date(),e=new Date();e.setMonth(e.getMonth()+3);
document.getElementById('f-start').value=fmt(s);document.getElementById('f-end').value=fmt(e);
setColor(PROJECT_TYPES[projects.length%PROJECT_TYPES.length].color);
}
document.getElementById('modal-overlay').classList.add('open');
}
function closeModal(){document.getElementById('modal-overlay').classList.remove('open');editId=null;}
async function saveProject(){
try{
const name=document.getElementById('f-name').value.trim();
if(!name){alert('Project name required');return;}
const existing=editId?findProject(editId):null;
const milestones=readMilestoneBuilder(existing);
const p={id:String(editId||Date.now()),name,desc:document.getElementById('f-desc').value.trim(),contact:document.getElementById('f-contact').value.trim()||'Unassigned',start:document.getElementById('f-start').value,end:document.getElementById('f-end').value,progress:parseInt(document.getElementById('f-progress').value)||0,status:document.getElementById('f-status').value,color:getColor(),category:(document.getElementById('f-category').value==='po'?'po':'tender'),tenderNo:document.getElementById('f-tender-no').value.trim(),tenderAmount:getCurrencyValue('f-tender-amount'),tenderRefDocs:multiPdfData['tender-ref'].slice(),poNo:document.getElementById('f-po-no').value.trim(),poAmount:getCurrencyValue('f-po-amount'),poRefDocs:multiPdfData['po-ref'].slice(),delay:document.getElementById('f-delay').value.trim(),mitigation:document.getElementById('f-mitigation').value.trim(),revisedTimeline:document.getElementById('f-revised-timeline').value.trim(),newDeadline:document.getElementById('f-new-deadline').value.trim(),eotInformed:document.getElementById('f-eot-informed').value.trim(),eotSubmitted:document.getElementById('f-eot-submitted').value.trim(),eotMpc:document.getElementById('f-eot-mpc').value.trim(),eotApproved:document.getElementById('f-eot-approved').value.trim(),eotNotes:document.getElementById('f-eot-notes').value.trim(),eotRefDocs:multiPdfData['eot-ref'].slice(),location:document.getElementById('f-location').value,milestones};
const isNew=!editId;
if(editId){
const i=findProjectIndex(editId);
if(i>=0){projects[i]=p;}else{projects.push(p);} // guard: if not found, add as new
}else projects.push(p);
closeModal();render();
await saveToFirebase();
if(isNew){
toastSuccess('Project Added', `"${name}" has been added successfully.`);
} else {
toastInfo('Project Updated', `Changes to "${name}" have been saved.`);
}
} catch(err){
console.error('saveProject error:',err);
alert('Save failed: '+err.message+'. Please check console for details.');
}
}
async function deleteFromModal(){
if(!editId)return;
if(!confirm('Are you sure you want to delete this project? This cannot be undone.'))return;
const idToDelete=String(editId);
const deletedProject=findProject(idToDelete);
const deletedName=deletedProject?deletedProject.name:'Project';
projects=projects.filter(x=>String(x.id)!==idToDelete);
editId=null;
closeModal();render();
await saveToFirebase();
toastWarning('Project Deleted', `"${deletedName}" has been removed.`);
}
function deleteProject(id){
if(!confirm('Delete this project?'))return;
projects=projects.filter(x=>String(x.id)!==String(id));render();
saveToFirebase();
}
// Modal closes only via Close button or Save — no backdrop click
document.getElementById('topbar-date').textContent=new Date().toLocaleDateString('en-MY',{weekday:'short',day:'numeric',month:'long',year:'numeric'}).toUpperCase();
const s0=new Date(),e0=new Date();e0.setMonth(e0.getMonth()+3);
document.getElementById('f-start').value=fmt(s0);document.getElementById('f-end').value=fmt(e0);
setColor(PROJECT_TYPES[0].color);
loadMilestoneBuilder([]); // init builder with one blank row
// ── EXPORT TO EXCEL ──
function exportToExcel(section){
const today = new Date();
const dateStr = today.toLocaleDateString('en-MY',{day:'2-digit',month:'2-digit',year:'numeric'}).replace(/\//g,'-');
// Multi-sheet for 'all'
if(section==='all'){
const wb=XLSX.utils.book_new();
['tender','po','completed'].forEach(sec=>{
const subList=sec==='tender'?projects.filter(p=>p.category==='tender'&&!isCompleted(p)):
sec==='po'?projects.filter(p=>p.category==='po'&&!isCompleted(p)):
projects.filter(p=>isCompleted(p));
if(subList.length){
const ws=buildExcelSheet(subList,sec);
XLSX.utils.book_append_sheet(wb,ws,sec==='tender'?'Tender':sec==='po'?'PO':'Completed');
}
});
XLSX.writeFile(wb,`RSMED_All_Projects_${dateStr}.xlsx`);
toastSuccess('Export Complete',`All projects exported to RSMED_All_Projects_${dateStr}.xlsx`);
return;
}
// Filter projects by section
let list, sheetName, fileName;
if(section==='tender'){
list = projects.filter(p=>p.category==='tender' && !isCompleted(p));
sheetName = 'Tender Projects';
fileName = `RSMED_Tender_Projects_${dateStr}.xlsx`;
} else if(section==='po'){
list = projects.filter(p=>p.category==='po' && !isCompleted(p));
sheetName = 'PO Projects';
fileName = `RSMED_PO_Projects_${dateStr}.xlsx`;
} else {
list = projects.filter(p=>isCompleted(p));
sheetName = 'Completed Projects';
fileName = `RSMED_Completed_Projects_${dateStr}.xlsx`;
}
if(!list.length){ toastWarning('No Data','No projects found in this section to export.'); return; }
// Build rows
const fmtAmt = v => v ? Number(v).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}) : '—';
const fmtDate= v => v ? new Date(v).toLocaleDateString('en-MY',{day:'2-digit',month:'short',year:'numeric'}) : '—';
const daysLeft= v => { if(!v) return '—'; const d=Math.ceil((new Date(v)-new Date())/864e5); return d<0?'Overdue ('+Math.abs(d)+'d)':d+'d left'; };
const headers = [
'No.','Project Name','Unit','Location','Status','Progress (%)',
'Contractor / PIC',
section==='po'?'PO No.':'Tender No.',
section==='po'?'PO Amount (MYR)':'Tender Amount (MYR)',
'Start Date','End Date','New Deadline','Days Left',
'Cause of Delay','Mitigation Plan','Revised Timeline',
'EOT Informed','EOT Submitted','EOT MPC/SIC','EOT Approved','EOT Notes',
'Milestones (Name | Start | End | Status)'
];
const rows = list.map((p,i)=>{
const effectiveEnd = p.newDeadline&&p.newDeadline.trim()?p.newDeadline:p.end;
const msText = (p.milestones||[]).map(m=>
`${m.name} | ${fmtDate(m.startDate)} | ${fmtDate(m.endDate)} | ${m.status}`
).join(' || ');
return [
i+1,
p.name||'',
getTypeLabel(p.color)||'',
p.location||'',
(BADGE[p.status]||['',''])[1]||p.status||'',
p.progress||0,
p.contact||'',
section==='po'?(p.poNo||'—'):(p.tenderNo||'—'),
section==='po'?fmtAmt(p.poAmount):fmtAmt(p.tenderAmount),
fmtDate(p.start),
fmtDate(p.end),
p.newDeadline?fmtDate(p.newDeadline):'—',
daysLeft(effectiveEnd),
p.delay||'',
p.mitigation||'',
p.revisedTimeline||'',
p.eotInformed?fmtDate(p.eotInformed):'',
p.eotSubmitted?fmtDate(p.eotSubmitted):'',
p.eotMpc?fmtDate(p.eotMpc):'',
p.eotApproved?fmtDate(p.eotApproved):'',
p.eotNotes||'',
msText||'—'
];
});
const ws = buildExcelSheet(list, section);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, fileName);
toastSuccess('Export Complete', `${list.length} project${list.length>1?'s':''} exported to "${fileName}".`);
}
function buildExcelSheet(list, section){
const fmtAmt = v => v ? Number(v).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}) : '—';
const fmtDate= v => v ? new Date(v).toLocaleDateString('en-MY',{day:'2-digit',month:'short',year:'numeric'}) : '—';
const daysLeft= v => { if(!v) return '—'; const d=Math.ceil((new Date(v)-new Date())/864e5); return d<0?'Overdue ('+Math.abs(d)+'d)':d+'d left'; };
const headers = [
'No.','Project Name','Unit','Location','Status','Progress (%)',
'Contractor / PIC',
section==='po'?'PO No.':'Tender No.',
section==='po'?'PO Amount (MYR)':'Tender Amount (MYR)',
'Start Date','End Date','New Deadline','Days Left',
'Cause of Delay','Mitigation Plan','Revised Timeline',
'EOT Informed','EOT Submitted','EOT MPC/SIC','EOT Approved','EOT Notes',
'Milestones (Name | Start | End | Status)'
];
const rows = list.map((p,i)=>{
const effectiveEnd = p.newDeadline&&p.newDeadline.trim()?p.newDeadline:p.end;
const msText = (p.milestones||[]).map(m=>
`${m.name} | ${fmtDate(m.startDate)} | ${fmtDate(m.endDate)} | ${m.status}`
).join(' || ');
return [
i+1, p.name||'', getTypeLabel(p.color)||'', p.location||'',
(BADGE[p.status]||['',''])[1]||p.status||'',
p.progress||0, p.contact||'',
section==='po'?(p.poNo||'—'):(p.tenderNo||'—'),
section==='po'?fmtAmt(p.poAmount):fmtAmt(p.tenderAmount),
fmtDate(p.start), fmtDate(p.end),
p.newDeadline?fmtDate(p.newDeadline):'—',
daysLeft(effectiveEnd),
p.delay||'', p.mitigation||'', p.revisedTimeline||'',
p.eotInformed?fmtDate(p.eotInformed):'',
p.eotSubmitted?fmtDate(p.eotSubmitted):'',
p.eotMpc?fmtDate(p.eotMpc):'',
p.eotApproved?fmtDate(p.eotApproved):'',
p.eotNotes||'', msText||'—'
];
});
const wsData = [headers, ...rows];
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = [
{wch:4},{wch:32},{wch:14},{wch:10},{wch:12},{wch:12},
{wch:28},{wch:20},{wch:20},
{wch:14},{wch:14},{wch:14},{wch:14},
{wch:30},{wch:30},{wch:30},
{wch:14},{wch:14},{wch:14},{wch:14},{wch:30},
{wch:60}
];
return ws;
}
// ════════════════════════════════════════════════════
// PRESENTATION MODE
// ════════════════════════════════════════════════════
let presSlides=[], presCurrentSlide=0;
const PRES_BADGE={
'pre-award':['#ede9fe','#4c1d95','Pre-Award'],
'on-track': ['#dcfce7','#166534','On Track'],
'delayed': ['#fee2e2','#991b1b','Delayed'],
'complete': ['#e0f2fe','#075985','Complete'],
};
function fmtPresDate(d){
if(!d) return '—';
return new Date(d).toLocaleDateString('en-MY',{day:'2-digit',month:'short',year:'numeric'});
}
function presStatusBadge(status){
const [bg,col,lbl]=PRES_BADGE[status]||['#e5e7eb','#374151','Active'];
return `
${lbl} `;
}
function presMsDot(status){
const c={done:'#22c55e',active:'#f59e0b',pending:'#6b7280',overdue:'#ef4444'};
return c[status]||'#6b7280';
}
// ── Build KPI Overview slide (Slide 1) ──
function buildKpiSlide(tender){
const active=tender.filter(p=>!isCompleted(p));
const total=active.length;
const avg=total?Math.round(active.reduce((s,p)=>s+p.progress,0)/total):0;
const atRisk=active.filter(p=>p.status==='delayed').length;
const totalAmt=active.reduce((s,p)=>s+(Number(p.tenderAmount)||0),0);
const fmtAmt=v=>v>=1e6?'MYR '+( v/1e6).toFixed(2)+' M':'MYR '+v.toLocaleString('en-MY',{minimumFractionDigits:0});
const dueSoonCount=active.filter(p=>isDueSoon(p)).length;
// Project summary cards (max 12 on overview)
const cards=active.map(p=>{
const effectiveEnd=p.newDeadline&&p.newDeadline.trim()?p.newDeadline:p.end;
const dl=Math.ceil((new Date(effectiveEnd)-new Date())/864e5);
const dlText=dl<0?'Overdue '+Math.abs(dl)+'d':dl===0?'Due Today':dl+'d left';
const dlClass=dl<0?'crit':dl<=90?'warn':'';
const soon=isDueSoon(p);
const risk=p.status==='delayed';
const cardClass=risk?'at-risk':soon?'due-soon':'';
const msSlice=(p.milestones||[]).slice(0,3);
const msHtml=msSlice.length?`
Milestones
${msSlice.map(m=>{
const mDot=presMsDot(m.status);
const mBg={done:'rgba(34,197,94,.15)',active:'rgba(245,158,11,.15)',pending:'rgba(107,114,128,.12)',overdue:'rgba(239,68,68,.15)'}[m.status]||'rgba(107,114,128,.12)';
const mCol={done:'#86efac',active:'#fcd34d',pending:'#9ca3af',overdue:'#fca5a5'}[m.status]||'#9ca3af';
return `
`;
}).join('')}
`:'';
const amtVal=p.tenderAmount?'MYR '+Number(p.tenderAmount).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
return `
${p.progress}%
${dlText}
${msHtml}
`;
}).join('');
return `
Active Tender Projects
${total}
Currently ongoing
At Risk / Delayed
${atRisk}
${atRisk===0?'All on track':'Needs attention'}
Total Tender Value
${fmtAmt(totalAmt)}
${dueSoonCount>0?dueSoonCount+' project'+(dueSoonCount>1?'s':'')+' due within 3 months':'No imminent deadlines'}
ACTIVE TENDER PROJECTS — CLICK A CARD TO VIEW DETAILS
${cards||'
No active tender projects
'}
`;
}
// ── Build detail slide for a single project ──
function buildDetailSlide(p, slideIdx){
const effectiveEnd=p.newDeadline&&p.newDeadline.trim()?p.newDeadline:p.end;
const dl=Math.ceil((new Date(effectiveEnd)-new Date())/864e5);
const dlText=dl<0?'Overdue by '+Math.abs(dl)+' days':dl===0?'Due Today':''+dl+' days left';
const dlColor=dl<0?'#f87171':dl<=90?'#fbbf24':'#86efac';
const amtVal=p.tenderAmount?'MYR '+Number(p.tenderAmount).toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}):'—';
const [bg,col,lbl]=PRES_BADGE[p.status]||['#e5e7eb','#374151','Active'];
const msRows=(p.milestones||[]).map(m=>{
const mBg={done:'rgba(34,197,94,.15)',active:'rgba(245,158,11,.15)',pending:'rgba(107,114,128,.12)',overdue:'rgba(239,68,68,.15)'}[m.status]||'rgba(107,114,128,.12)';
const mCol={done:'#86efac',active:'#fcd34d',pending:'#9ca3af',overdue:'#fca5a5'}[m.status]||'#9ca3af';
const mDate=m.startDate?(m.endDate&&m.endDate!==m.startDate?fmtPresDate(m.startDate)+' → '+fmtPresDate(m.endDate):fmtPresDate(m.startDate)):'—';
// Photos per milestone row (Option B)
const photos=m.photos&&m.photos.length?`
${m.photos.map((src,pi)=>`
`).join('')}
`:'';
return `
${m.name}
${mDate}
${m.status}
${photos}
`;
}).join('');
const delayHtml=(p.delay&&p.delay.trim())?`
⚠ Risk & Delay
${p.delay}
${p.mitigation?`
Mitigation Plan
${p.mitigation}
`:''}
`:'';
return `
🏠 Overview
Progress
${p.progress}%
${dlText}
Project Details
Tender No.
${p.tenderNo||'—'}
Contractor / PIC
${p.contact||'—'}
Start → Original Deadline
${fmtPresDate(p.start)} → ${fmtPresDate(p.end)}
${p.newDeadline?`
Revised Deadline
${fmtPresDate(p.newDeadline)}
`:''}
${p.desc?`
`:''}
${msRows?`
`:''}
${delayHtml}
`;
}
// ── Open / Close ──
function openPres(){
const tender=projects.filter(p=>p.category==='tender'&&!isCompleted(p));
presSlides=[];
presCurrentSlide=0;
// Build all slides
const stage=document.getElementById('pres-stage');
stage.innerHTML='';
// Slide 0: KPI + summary
stage.innerHTML+=buildKpiSlide(tender);
presSlides.push({type:'overview'});
// Slides 1+: one per project detail
tender.forEach((p,i)=>{
stage.innerHTML+=buildDetailSlide(p, i+1);
presSlides.push({type:'detail', id:p.id});
});
presUpdateUI();
document.getElementById('pres-overlay').classList.add('open');
document.body.style.overflow='hidden';
}
function closePres(){
document.getElementById('pres-overlay').classList.remove('open');
document.body.style.overflow='';
}
function presOpenDetail(pid){
const idx=presSlides.findIndex(s=>s.type==='detail'&&String(s.id)===String(pid));
if(idx>=0){ presCurrentSlide=idx; presUpdateUI(); }
}
function presNav(dir){
presCurrentSlide=Math.max(0,Math.min(presSlides.length-1, presCurrentSlide+dir));
presUpdateUI();
}
function presGoHome(){
presCurrentSlide=0;
presUpdateUI();
}
function presUpdateUI(){
// Show correct slide
document.querySelectorAll('.pres-slide').forEach((el,i)=>{
el.classList.toggle('active', i===presCurrentSlide);
});
// Update info bar
document.getElementById('pres-slide-info').textContent=
'SLIDE '+(presCurrentSlide+1)+' OF '+presSlides.length;
// Update nav buttons
document.getElementById('pres-prev').disabled=presCurrentSlide===0;
document.getElementById('pres-next').disabled=presCurrentSlide===presSlides.length-1;
// Update dots (max 10 shown)
const dots=document.getElementById('pres-dots');
const max=Math.min(presSlides.length,10);
dots.innerHTML=Array.from({length:max},(_,i)=>
`
`
).join('');
}
// Keyboard navigation
document.addEventListener('keydown',e=>{
if(!document.getElementById('pres-overlay').classList.contains('open')) return;
if(e.key==='ArrowRight'||e.key==='ArrowDown') presNav(1);
if(e.key==='ArrowLeft'||e.key==='ArrowUp') presNav(-1);
if(e.key==='Escape') closePres();
});
function toggleExportDropdown(){
const dd=document.getElementById('export-dropdown');
dd.classList.toggle('open');
}
function closeExportDropdown(){
document.getElementById('export-dropdown').classList.remove('open');
}
// Close dropdown when clicking outside
document.addEventListener('click',e=>{
const wrap=document.getElementById('export-wrap');
if(wrap && !wrap.contains(e.target)){
closeExportDropdown();
}
});
// ── Currency input formatting ──
function fmtCurrencyInput(el){
// Allow only digits and single decimal point
let raw=el.value.replace(/[^0-9.]/g,'');
const parts=raw.split('.');
if(parts.length>2) raw=parts[0]+'.'+parts.slice(1).join('');
// Format integer part with commas
const intPart=parts[0].replace(/\B(?=(\d{3})+(?!\d))/g,',');
el.value=parts.length>1?intPart+'.'+parts[1]:intPart;
}
function fmtCurrencyBlur(el){
const num=parseFloat(el.value.replace(/,/g,''));
if(!isNaN(num)) el.value=num.toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2});
}
// Returns raw numeric string for saving (strip commas)
function getCurrencyValue(id){
const val=document.getElementById(id).value.replace(/,/g,'');
return isNaN(parseFloat(val))?'':String(parseFloat(val));
}
// Sets currency field with formatted value
function setCurrencyValue(id, val){
const el=document.getElementById(id);
if(!el) return;
const num=parseFloat(String(val||'').replace(/,/g,''));
el.value=isNaN(num)?'':(num.toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}));
}
// ── Presentation photo fullscreen ──
function presOpenPhoto(src){
let ov=document.getElementById('pres-photo-overlay');
if(!ov){
ov=document.createElement('div');
ov.id='pres-photo-overlay';
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
ov.onclick=()=>ov.remove();
const img=document.createElement('img');
img.id='pres-photo-img';
img.style.cssText='max-width:90vw;max-height:90vh;border-radius:8px;box-shadow:0 24px 64px rgba(0,0,0,.6);object-fit:contain;';
const closeBtn=document.createElement('button');
closeBtn.textContent='✕';
closeBtn.style.cssText='position:absolute;top:24px;right:24px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:white;width:40px;height:40px;border-radius:50%;font-size:16px;cursor:pointer;';
closeBtn.onclick=e=>{e.stopPropagation();ov.remove();};
ov.appendChild(img);
ov.appendChild(closeBtn);
document.body.appendChild(ov);
}
document.getElementById('pres-photo-img').src=src;
document.getElementById('pres-photo-overlay').style.display='flex';
}
// ── Milestone PDF attachment ──
let _msPdfCurrentBase64='', _msPdfCurrentName='';
function msPdfBtnClick(btn){
if(btn.dataset.pdf){
// Already has PDF — show view/remove menu
if(confirm('PDF attached: "'+btn.dataset.pdfName+'"\n\nClick OK to remove it, or Cancel to keep it.')){
btn.dataset.pdf='';
btn.dataset.pdfName='';
btn.classList.remove('has-pdf');
}
} else {
// No PDF — trigger file picker
const input=btn.nextElementSibling;
if(input&&input.classList.contains('ms-pdf-input')) input.click();
}
}
function handleMsPdfAttach(input){
const file=input.files[0];
if(!file||file.type!=='application/pdf') return;
const btn=input.previousElementSibling;
const reader=new FileReader();
reader.onload=ev=>{
btn.dataset.pdf=ev.target.result;
btn.dataset.pdfName=file.name;
btn.classList.add('has-pdf');
toastDocument('PDF Attached','"'+file.name+'" attached to milestone.');
};
reader.readAsDataURL(file);
input.value='';
}
// ── Milestone PDF Viewer (Overview tab) ──
function openMsPdfViewer(base64, name){
_msPdfCurrentBase64=base64;
_msPdfCurrentName=name||'document.pdf';
document.getElementById('ms-pdf-modal-title').textContent=_msPdfCurrentName;
document.getElementById('ms-pdf-iframe').src=base64;
document.getElementById('ms-pdf-viewer-overlay').classList.add('open');
}
function closeMsPdfViewer(e){
if(e&&e.target!==document.getElementById('ms-pdf-viewer-overlay')) return;
document.getElementById('ms-pdf-viewer-overlay').classList.remove('open');
document.getElementById('ms-pdf-iframe').src='';
_msPdfCurrentBase64='';
}
function downloadMsPdf(){
if(!_msPdfCurrentBase64) return;
const a=document.createElement('a');
a.href=_msPdfCurrentBase64;
a.download=_msPdfCurrentName;
a.click();
}
function render(){updateKPIs();renderList();buildFilterBars();}
// Only auto-load data if session is already valid (returning user)
if(checkSession()){
loadFromFirebase();
buildFilterBars();
} else {
buildFilterBars(); // still build filter UI for when login completes
}
function fmtCurrencyBlur(el){
const num=parseFloat(el.value.replace(/,/g,''));
if(!isNaN(num)) el.value=num.toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2});
}
// Returns raw numeric string for saving (strip commas)
function getCurrencyValue(id){
const val=document.getElementById(id).value.replace(/,/g,'');
return isNaN(parseFloat(val))?'':String(parseFloat(val));
}
// Sets currency field with formatted value
function setCurrencyValue(id, val){
const el=document.getElementById(id);
if(!el) return;
const num=parseFloat(String(val||'').replace(/,/g,''));
el.value=isNaN(num)?'':(num.toLocaleString('en-MY',{minimumFractionDigits:2,maximumFractionDigits:2}));
}
// ── Presentation photo fullscreen ──
function presOpenPhoto(src){
let ov=document.getElementById('pres-photo-overlay');
if(!ov){
ov=document.createElement('div');
ov.id='pres-photo-overlay';
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:5000;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
ov.onclick=()=>ov.remove();
const img=document.createElement('img');
img.id='pres-photo-img';
img.style.cssText='max-width:90vw;max-height:90vh;border-radius:8px;box-shadow:0 24px 64px rgba(0,0,0,.6);object-fit:contain;';
const closeBtn=document.createElement('button');
closeBtn.textContent='✕';
closeBtn.style.cssText='position:absolute;top:24px;right:24px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:white;width:40px;height:40px;border-radius:50%;font-size:16px;cursor:pointer;';
closeBtn.onclick=e=>{e.stopPropagation();ov.remove();};
ov.appendChild(img);
ov.appendChild(closeBtn);
document.body.appendChild(ov);
}
document.getElementById('pres-photo-img').src=src;
document.getElementById('pres-photo-overlay').style.display='flex';
}
function render(){updateKPIs();renderList();buildFilterBars();}
// Only auto-load data if session is already valid (returning user)
if(checkSession()){
loadFromFirebase();
buildFilterBars();
} else {
buildFilterBars(); // still build filter UI for when login completes
}
PROJECT. TRACKER — TENDER OVERVIEW
SLIDE 1 OF 1
✕ Exit Presentation